目录
  1. 1. 一、Session 管理的核心问题
  2. 2. 二、核心数据结构:JSONL Transcript
    1. 2.1. 关键设计:parentUuid 链
  3. 3. 三、Session 生命周期
  4. 4. 四、Transcript 存储路径
  5. 5. 五、消息写入机制
    1. 5.1. 5.1 追加写入(性能优先)
    2. 5.2. 5.2 墓碑机制(Tombstone)
    3. 5.3. 5.3 进度消息(Ephemeral)
  6. 6. 六、Session 恢复(sessionRestore.ts)
    1. 6.1. 关键:在 React 渲染前完成状态恢复
  7. 7. 七、多路恢复触发点
  8. 8. 八、SessionStart Hooks
  9. 9. 九、并发会话管理
  10. 10. 十、Worktree Session(分叉会话)
  11. 11. 十一、面试要点
  12. 12. 十二、JSONL 文件格式深度解析
    1. 12.1. 12.1 文件路径规则的完整推导
    2. 12.2. 12.2 Session ID 的生成策略
    3. 12.3. 12.3 每行 JSON 的完整字段结构
    4. 12.4. 12.4 元数据专用 Entry 类型
  13. 13. 十三、墓碑机制(Tombstone)深度实现
    1. 13.1. 13.1 为什么用追加写而非覆盖
    2. 13.2. 13.2 removeMessageByUuid() 的两条路径
    3. 13.3. 13.3 墓碑 vs 物理删除的权衡
    4. 13.4. 13.4 会话列表中的”已删除”过滤
  14. 14. 十四、写入策略与数据一致性
    1. 14.1. 14.1 异步批量写:enqueueWrite + drainWriteQueue
    2. 14.2. 14.2 崩溃时的数据一致性保证
    3. 14.3. 14.3 文件锁(history.jsonl 专用)
  15. 15. 十五、会话恢复完整代码路径
    1. 15.1. 15.1 从 --resume Flag 到 Messages 数组
    2. 15.2. 15.2 loadTranscriptFile() 的核心逻辑
    3. 15.3. 15.3 哪些消息会被跳过或转换
  16. 16. 十六、消息类型序列化的特殊处理
    1. 16.1. 16.1 图片内容的处理
    2. 16.2. 16.2 history.jsonl vs <sessionId>.jsonl 的区别
    3. 16.3. 16.3 ToolUseBlock 的序列化注意点
  17. 17. 十七、多 Agent 场景下的 Session 文件关系
    1. 17.1. 17.1 SubAgent 的文件布局
    2. 17.2. 17.2 Leader 与 SubAgent 的写入隔离
    3. 17.3. 17.3 SubAgent 的元数据持久化
    4. 17.4. 17.4 SubAgent Resume 的特殊处理
  18. 18. 十八、面试深度题
  19. 19. 十一、Session 生命周期与状态机
    1. 19.1. 11.1 Session 持久化触发时机
    2. 19.2. 11.2 与数据库 WAL 的类比
    3. 19.3. 11.3 Portable Session Storage
    4. 19.4. 11.4 会话管理最佳实践
  20. 20. 涉及源文件
【Claude Code源码剖析】22-Session 管理与状态恢复

⚠️ 学习声明:本文档基于 Claude Code 2.1.88 源码分析整理,仅供个人学习研究使用,不做任何商业用途。

这是 CC 实现”可中断、可恢复 Agent”的工程核心。


一、Session 管理的核心问题

Agent 执行可能:

  • 被用户 Ctrl+C 中断
  • 因网络断开而中止
  • /clear 清空后重新开始
  • 通过 --continue--resume 明确续接

CC 的 Session 管理系统保证:无论何种中断,用户都能从中断点继续,不丢失对话历史和执行状态。


二、核心数据结构:JSONL Transcript

每个 Session 的完整历史存储为一个 JSONL 文件(每行一个 JSON 对象):

路径:~/.claude/projects/<project-slug>/<sessionId>.jsonl

每行的 Entry 类型:
├── user ← 用户消息
├── assistant ← Claude 回复(含 tool_use blocks)
├── attachment ← 文件/图片附件
├── system ← 系统消息(compact 边界等)
├── progress ← 工具执行进度消息(不参与 parentUuid 链)
├── summary ← 压缩摘要(compact 后插入)
├── file-history-snapshot ← 文件修改历史快照
├── attribution-snapshot ← 归因快照(用于成本分析)
└── marble-origami-snapshot ← 上下文折叠快照(context collapse)

关键设计:parentUuid 链

// 每条消息有 uuid 和 parentUuid,形成链表
type Message = {
uuid: UUID
parentUuid: UUID | null // 指向前一条消息
// ...
}

这个链表结构支持:

  • 顺序恢复:按 parentUuid 重建对话链
  • 分叉会话(worktree 模式):多条链共存
  • 跳过废弃消息:progress 消息不参与链(避免”链断裂”)

三、Session 生命周期

      用户运行 claude


┌──────────────────────┐
│ processSessionStart │
│ Hooks 执行 │
│ Plugin Hooks 加载 │
└──────────┬───────────┘

┌──────────▼───────────┐
│ Session 初始化 │
│ getTranscriptPath() │
│ sessionId 确定 │
└──────────┬───────────┘

┌──────────▼───────────┐
│ REPL 主循环 │
│ 消息写入 JSONL │
└──────────┬───────────┘

┌──────────▼───────────┐
│ 中断/退出 │
│ gracefulShutdown │
│ cleanupRegistry 执行 │
└──────────────────────┘

四、Transcript 存储路径

// 标准路径
getTranscriptPath(): string
// → ~/.claude/projects/<project-slug>/<sessionId>.jsonl

// SubAgent 的 Transcript 路径
getAgentTranscriptPath(agentId: AgentId): string
// → ~/.claude/projects/<project-slug>/subagents/<agentId>.jsonl

// 支持子目录分组(workflow runs 等)
setAgentTranscriptSubdir(agentId, subdir)
// → ~/.claude/projects/<project-slug>/subagents/<subdir>/<agentId>.jsonl

项目目录 Slug 规则:由 getOriginalCwd()sanitizePath() 处理后生成,确保不同项目的 Session 文件隔离。


五、消息写入机制

5.1 追加写入(性能优先)

// 消息以 append 方式写入 JSONL(非覆写)
// 优势:O(1) 写入,不会因文件过大而变慢
// 限制:已写入内容不可修改(通过"墓碑"标记删除)
await appendFile(transcriptPath, jsonStringify(entry) + '\n')

5.2 墓碑机制(Tombstone)

当需要”删除”或”修改”历史消息时(如 /undo),不直接修改 JSONL,而是追加一条墓碑记录

type TombstoneEntry = {
type: 'tombstone'
uuid: UUID // 被删除消息的 ID
}

读取时跳过被标记的 UUID。墓碑重写有 50MB 限制(防止 OOM)。

5.3 进度消息(Ephemeral)

BashTool、PowerShellTool、MCPTool 的实时进度不写入 JSONL:

const EPHEMERAL_PROGRESS_TYPES = new Set([
'bash_progress',
'powershell_progress',
'mcp_progress',
'sleep_progress',
])
// 这些是 UI-only,不持久化,不参与 parentUuid 链

六、Session 恢复(sessionRestore.ts)

--continue--resume <sessionId> 时,执行恢复流程:

async function resumeSession(sessionId: string): Promise<ResumeResult> {
// 1. 读取 JSONL transcript
const messages = await loadTranscriptFile(transcriptPath)

// 2. 恢复文件历史状态(FileHistory Snapshots)
fileHistoryRestoreStateFromLog(snapshots, setAppState)

// 3. 恢复代码归因状态(ant-only: COMMIT_ATTRIBUTION)
attributionRestoreStateFromLog(attributionSnapshots, setAppState)

// 4. 恢复 Context Collapse 状态
restoreFromEntries(contextCollapseCommits, contextCollapseSnapshot)

// 5. 恢复 Todo 列表(从 transcript 最后一条 TodoWrite 提取)
const todos = extractTodosFromTranscript(messages)

// 6. 恢复 Agent 配置(模型、AgentType)
restoreAgentFromSession(agentSetting, ...)

return { messages, fileHistorySnapshots, attributionSnapshots, ... }
}

关键:在 React 渲染前完成状态恢复

// main.tsx 中的关键顺序
// 1. 计算初始状态(同步)
const initialState = computeInitialState()
// 2. 恢复 session 状态(可能涉及文件读取)
const resumeResult = await resumeFromSession(sessionId)
// 3. 首次 React render(状态已就绪,无闪烁)
render(<App initialState={...} resumeMessages={...} />)

七、多路恢复触发点

触发方式 场景 恢复范围
--continue 继续上次会话 最近 session 的完整 transcript
--resume <id> 指定会话 ID 指定 session 的 transcript
/clear 清空当前对话 不恢复,开新 session
/compact 压缩后继续 用 summary 替换历史,继续
Swarm 重连 Teammate 崩溃重启 从 TeamFile + 邮箱重建状态

八、SessionStart Hooks

每次会话启动(包括恢复)都执行 processSessionStartHooks(source)

type source = 'startup' | 'resume' | 'clear' | 'compact'

// 执行顺序:
// 1. loadPluginHooks() —— 加载外部 plugin hooks(memoized)
// 2. executeSetupHooks() —— 如果是 'startup',执行 setup hooks
// 3. executeSessionStartHooks() —— 执行 session-start hooks
// 4. 更新 watchPaths(文件监听路径)

// 特殊能力:hook 可以注入 initialUserMessage
// → 用于 proactive mode 自动触发对话
takeInitialUserMessage(): string | undefined

九、并发会话管理

CC 支持多个终端同时运行(并发 Session):

// concurrentSessions.ts
updateSessionName(sessionId, title) // 在并发列表中更新会话标题

// 每个 Session 独立的 JSONL 文件
// 通过 sessionId(UUID)区分
// ~/.claude/projects/<slug>/<sessionId>.jsonl

十、Worktree Session(分叉会话)

Git Worktree 模式下,每个 worktree 有独立的 Session:

type PersistedWorktreeSession = {
worktreePath: string
sessionId: SessionId
// ...
}

// saveWorktreeState() → 持久化 worktree 与 session 的绑定关系
// getCurrentWorktreeSession() → 查询当前 worktree 对应的 session

十一、面试要点

Q:CC 如何保证 Agent 可中断后恢复?

每条消息实时 append 写入 JSONL,中断时已写入的内容不丢失。--resume 时读取 JSONL 重建 Message[],恢复 FileHistory、Attribution、Todo 等状态,然后继续 Agentic Loop。关键是”写入先于执行”——消息先写入磁盘,再执行工具,确保 crash 后重放是安全的。

Q:为什么用 JSONL 而不是 SQLite 或普通 JSON?

  1. **追加写入 O(1)**:JSONL 追加不需要读-改-写,对频繁写入(每个 token 流)性能最佳;2. 流式读取:可以边读边处理,不需要加载整个文件到内存;3. 崩溃安全:只需要 fsync 最后一行,不像 SQLite 需要事务管理;4. 调试友好:纯文本,可直接用 tail -f 监控。

Q:进度消息(bash_progress)为什么不写入 JSONL?

进度消息是高频(1/秒)的 UI 状态,恢复 Session 时不需要重放这些中间状态,只需要最终结果。写入会使 JSONL 文件急剧膨胀(bash 执行10分钟就会产生600条进度消息)。它们通过 React state 直接更新 UI,REPL.tsx 对同一 toolUse 的进度消息做”原地替换”而非追加。


十二、JSONL 文件格式深度解析

12.1 文件路径规则的完整推导

~/.claude/projects/<project-slug>/<sessionId>.jsonl

项目 slug 的生成过程(源自 src/utils/sessionStoragePortable.tssanitizePath()):

const MAX_SANITIZED_LENGTH = 200  // 避免超出文件系统 255 字节限制

export function sanitizePath(name: string): string {
// 步骤 1:所有非字母数字字符替换为连字符
const sanitized = name.replace(/[^a-zA-Z0-9]/g, '-')

// 步骤 2:短路径直接返回(≤200 chars)
if (sanitized.length <= MAX_SANITIZED_LENGTH) {
return sanitized
}

// 步骤 3:长路径截断 + hash 后缀(保证唯一性)
// Bun 和 Node.js 使用不同的 hash 函数(Bun.hash vs simpleHash)
const hash = typeof Bun !== 'undefined'
? Bun.hash(name).toString(36)
: simpleHash(name)
return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${hash}`
}

实际例子

  • 项目路径 /Users/alice/myapp → slug -Users-alice-myapp
  • 项目路径 /home/bob/very-long-project-name-...-home-bob-very-long-...-<hash36>
  • 最终 JSONL 路径:~/.claude/projects/-Users-alice-myapp/<uuid>.jsonl

注意:CLI 用 Bun.hash,SDK 用 Node.js simpleHash,对超长路径会产生不同 hash 后缀。findProjectDir() 提供前缀扫描兜底逻辑来容忍这种差异。

12.2 Session ID 的生成策略

Session ID 不是 自定义格式,而是标准 UUID v4(随机数生成):

// src/bootstrap/state.ts
import { randomUUID } from 'src/utils/crypto.js'

// 初始 State 构造时直接生成
const state: State = {
sessionId: randomUUID() as SessionId,
// ...
}

生成时机

  1. 进程启动getInitialState() 调用 randomUUID() 生成一次
  2. **/clear**:调用 regenerateSessionId(),生成新 UUID,sessionProjectDir 重置为 null
  3. **--resume <id>**:调用 switchSession(asSessionId(customId)),直接使用用户指定的 UUID

碰撞概率:UUID v4 有 122 位随机性,在 $10^{18}$ 次生成前碰撞概率 < 50%,对本地 CLI 场景完全忽略不计。

parentSessionId 追踪

export function regenerateSessionId(
options: { setCurrentAsParent?: boolean } = {},
): SessionId {
if (options.setCurrentAsParent) {
STATE.parentSessionId = STATE.sessionId // plan mode → impl 时保留血统
}
STATE.planSlugCache.delete(STATE.sessionId)
STATE.sessionId = randomUUID() as SessionId
STATE.sessionProjectDir = null // 重置,避免跨目录写入
return STATE.sessionId
}

12.3 每行 JSON 的完整字段结构

JSONL 中的 TranscriptMessage(user/assistant/system/attachment 类型)的完整序列化结构:

// 一条典型的 user 消息(磁盘上的真实格式)
{
// ---- parentUuid 链字段 ----
"uuid": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"parentUuid": "a3bb189e-8bf9-3888-9912-ace4e6543002",
"isSidechain": false,
"logicalParentUuid": null, // compact 边界后首条消息的"逻辑父"

// ---- 消息内容(来自 Anthropic SDK MessageParam)----
"type": "user",
"message": {
"role": "user",
"content": [
{ "type": "text", "text": "帮我写一个 fibonacci 函数" }
]
},

// ---- Session 级元数据 ----
"sessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"userType": "external",
"entrypoint": "cli",
"cwd": "/Users/alice/myapp",
"version": "1.0.42",
"gitBranch": "main",
"slug": null,
"promptId": "7f3d9e1a-...", // 关联 OTel 追踪
"agentId": null, // SubAgent 时有值
"teamName": null,
"agentName": null
}

assistant 消息(含 ToolUseBlock):

{
"uuid": "b5e7f1a2-...",
"parentUuid": "f47ac10b-...",
"type": "assistant",
"message": {
"role": "assistant",
"content": [
{
"type": "text",
"text": "我来帮你写 fibonacci 函数:"
},
{
"type": "tool_use",
"id": "toolu_01ABC123",
"name": "Write",
"input": {
"file_path": "/Users/alice/myapp/fib.py",
"content": "def fib(n): ..."
}
}
],
// Anthropic API 返回的 usage 信息
"usage": {
"input_tokens": 1234,
"output_tokens": 87,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 1100
},
"model": "claude-opus-4-5",
"stop_reason": "tool_use"
},
"sessionId": "...",
"cwd": "/Users/alice/myapp",
// ... 其他元数据字段
}

tool_result(user 消息中的 ToolResultBlock)

{
"type": "user",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01ABC123",
"content": [
{ "type": "text", "text": "文件已写入" }
]
}
]
},
"sourceToolAssistantUUID": "b5e7f1a2-..." // 指向发起工具调用的 assistant 消息
}

12.4 元数据专用 Entry 类型

除对话消息外,JSONL 还包含多种元数据 Entry(不进入 Anthropic API 调用):

custom-title     — 用户通过 /rename 设置的会话标题
ai-title — 模型自动生成的会话标题
last-prompt — 最近一条用户 prompt 的摘要(供 --resume 列表展示)
tag — 会话标签(归类/搜索用)
mode — 当前模式(coordinator / normal)
agent-setting — Agent 配置(模型选择等)
worktree-state — Worktree 绑定状态
pr-link — 关联的 PR 链接
content-replacement — compact 时的内容替换记录
marble-origami-commit / snapshot — Context Collapse 状态

这些 Entry 被 appendEntry() 路由到不同分支直接写入,无需读取已有内容,因此不依赖 getSessionMessages()


十三、墓碑机制(Tombstone)深度实现

13.1 为什么用追加写而非覆盖

JSONL 追加写有三个关键优势:

  1. O(1) 写入:无论文件多大,append 一行的时间恒定
  2. 崩溃安全:每行独立,写入一行不影响其他行;若在 append 时崩溃,最多丢失最后一行,已有内容完好
  3. 无读-改-写竞争:不需要先读文件、再完整覆写,避免并发写冲突

但追加写有个问题:已写入的内容无法物理删除。当流式接收 Claude 回复中途出错(如网络断开),已写入的半截 assistant 消息会残留在 JSONL 中,恢复时会产生”孤儿消息”(dangling ref)。

解决方案就是墓碑(Tombstone):通过物理删除目标行来移除特定消息。

13.2 removeMessageByUuid() 的两条路径

// src/utils/sessionStorage.ts

async removeMessageByUuid(targetUuid: UUID): Promise<void> {
// ...
const fh = await fsOpen(this.sessionFile, 'r+') // 读写模式打开
const { size } = await fh.stat()

// === 快路径(Fast Path):读尾部 64KB ===
// 目标几乎总是最近写入的条目,64KB 能覆盖绝大多数情况
const LITE_READ_BUF_SIZE = 64 * 1024 // 64KB
const chunkLen = Math.min(size, LITE_READ_BUF_SIZE)
const buf = Buffer.allocUnsafe(chunkLen)
await fh.read(buf, 0, chunkLen, size - chunkLen)

// 搜索 `"uuid":"<targetUuid>"` 字节模式(而非 parentUuid)
const needle = `"uuid":"${targetUuid}"`
const matchIdx = tail.lastIndexOf(needle)

if (matchIdx >= 0) {
// 找到目标行的起止位置
// ftruncate 截断到行首,再 write 追加后续行
// 如果目标是最后一行,afterLen=0,仅需一次 ftruncate
await fh.truncate(absLineStart)
if (afterLen > 0) {
await fh.write(tail, lineEnd, afterLen, absLineStart)
}
return // 快路径完成
}

// === 慢路径(Slow Path):完整文件重写 ===
// 仅当目标不在最后 64KB 时触发(极罕见)
if (fileSize > MAX_TOMBSTONE_REWRITE_BYTES) { // 50MB 限制
logForDebugging(`Skipping tombstone removal: session file too large`)
return // 超过 50MB 则放弃,防止 OOM
}
const content = await readFile(this.sessionFile, { encoding: 'utf-8' })
const lines = content.split('\n').filter(line => {
const entry = jsonParse(line)
return entry.uuid !== targetUuid // 过滤掉目标行
})
await writeFile(this.sessionFile, lines.join('\n'))
}

核心设计决策

  • 搜索 "uuid":"..." 而非裸 UUID,避免误匹配 parentUuid 字段
  • UUID 是纯 ASCII,字节级搜索安全(不需要 UTF-8 解码)
  • 50MB 上限防止对大文件做全量内存操作(实际会话可达数 GB)

13.3 墓碑 vs 物理删除的权衡

维度 追加墓碑标记 物理行删除(CC 实际采用)
写放大 低(O(1)追加) 中(尾部 64KB 读+写)
恢复复杂度 高(读时需过滤) 低(直接不存在)
部分覆盖场景 简单 需要精确 ftruncate
50MB+ 文件 始终可用 跳过(接受残留)
并发安全 无需锁(append) 需要文件句柄(r+)

CC 选择物理行删除(而非逻辑墓碑标记),原因是恢复时无需过滤逻辑,loadTranscriptFile() 直接解析所有行即为有效消息,降低了读取复杂度。

13.4 会话列表中的”已删除”过滤

JSONL 级别没有”整个会话删除”的概念,但 listSessions() 在列出可恢复会话时,会读取每个 .jsonl 文件的元数据尾部readLiteMetadata)。若某 session 被用户删除,对应的 .jsonl 文件会被整个物理删除,listSessions() 扫描 projects/ 目录时自然不会出现。


十四、写入策略与数据一致性

14.1 异步批量写:enqueueWrite + drainWriteQueue

CC 的 JSONL 写入并非同步阻塞,而是采用异步批量写策略:

class Project {
private FLUSH_INTERVAL_MS = 100 // 100ms 批量窗口
private readonly MAX_CHUNK_BYTES = 100 * 1024 * 1024 // 单批次最大 100MB

private writeQueues = new Map<
string, // filePath
Array<{ entry: Entry; resolve: () => void }>
>()

private scheduleDrain(): void {
if (this.flushTimer) return // 已有定时器,不重复调度
this.flushTimer = setTimeout(async () => {
this.flushTimer = null
await this.drainWriteQueue() // 批量刷盘
if (this.writeQueues.size > 0) {
this.scheduleDrain() // 若还有待写,继续调度
}
}, this.FLUSH_INTERVAL_MS)
}

private async drainWriteQueue(): Promise<void> {
for (const [filePath, queue] of this.writeQueues) {
const batch = queue.splice(0) // 一次性取出所有待写条目

let content = ''
for (const { entry, resolve } of batch) {
const line = jsonStringify(entry) + '\n'
// 分块:单次 append 不超过 100MB
if (content.length + line.length >= this.MAX_CHUNK_BYTES) {
await this.appendToFile(filePath, content)
content = ''
}
content += line
}
if (content.length > 0) {
await this.appendToFile(filePath, content)
}
}
}
}

写入流程

  1. appendEntry()enqueueWrite() → 推入对应文件的 Queue
  2. scheduleDrain() 设置 100ms 定时器(已有定时器则跳过)
  3. 100ms 后 drainWriteQueue() 一次性 appendFile 所有积累的行
  4. 每个 enqueueWrite 返回一个 Promise,resolve 在其所属 batch 写盘后触发

这种设计将高频写入(每条消息)合并为低频 I/O(每 100ms 一次),大幅减少系统调用次数。

14.2 崩溃时的数据一致性保证

关键问题:如果进程在 drainWriteQueue 执行过程中崩溃,会丢失多少数据?

答案:最多丢失 100ms 内的写入

CC 的一致性策略:

  1. 消息先入 Queue,再执行 ToolinsertMessageChain() 写入 Queue 后,Tool 才执行。即使 Tool 执行期间崩溃,消息 Queue 数据已安全
  2. 实际上是 pending 状态:Queue 中的条目尚未落盘,若进程在 100ms 窗口内崩溃,会丢失这批数据
  3. 清理时强制刷盘:进程退出时 cleanupRegistry 执行 flush(),等待所有 pending writes 完成
// 清理处理器(进程退出时调用)
registerCleanup(async () => {
await project?.flush() // 等待所有队列写完
project?.reAppendSessionMetadata() // 重写元数据到尾部
})

flush() 的实现

async flush(): Promise<void> {
if (this.flushTimer) {
clearTimeout(this.flushTimer)
this.flushTimer = null
await this.drainWriteQueue() // 立即刷盘
}
await this.activeDrain // 等待当前进行中的 drain

if (this.pendingWriteCount === 0) return

// 等待所有 trackWrite 操作完成
return new Promise<void>(resolve => {
this.flushResolvers.push(resolve)
})
}

权衡:CC 接受”崩溃时丢失最多 100ms 写入”的代价,换取更高的吞吐量。对于 Claude 对话场景(消息频率远低于 10/s),这几乎不是问题。

14.3 文件锁(history.jsonl 专用)

history.jsonl(prompt 历史,Up-arrow 浏览)使用了文件锁

// src/history.ts
release = await lock(historyPath, {
stale: 10000, // 10s 后认为锁失效(进程死亡场景)
retries: {
retries: 3,
minTimeout: 50, // 50ms 起始,指数退避
},
})
await appendFile(historyPath, jsonLines.join(''), { mode: 0o600 })

<sessionId>.jsonl(会话 transcript)不使用锁,因为:

  • 每个 session 有唯一的 sessionId + 对应文件
  • 单进程顺序写入,无多进程并发写同一文件的场景
  • 使用锁反而会增加延迟

十五、会话恢复完整代码路径

15.1 从 --resume Flag 到 Messages 数组

CLI 参数解析
│ --resume <sessionId> 或 --continue

main.tsx / entrypoint
│ customSessionId = argv['resume']
│ switchSession(asSessionId(customSessionId)) ← 切换全局 sessionId

setup.ts → setup()
│ if (customSessionId) switchSession(asSessionId(customSessionId))

sessionRestore(sessionRestore.ts)
│ loadConversationForResume(transcriptPath)

loadTranscriptFile(filePath)
│ 逐行解析 JSONL
│ 重建 UUID → Message Map
│ 识别 leafUuids(无子节点的消息)

buildConversationChain(messages, leafMessage)
│ 从最新 leaf 逆向追溯 parentUuid 链
│ 构建 TranscriptMessage[](时间正序)

状态恢复(并行)
├── fileHistoryRestoreStateFromLog() ← 文件修改历史
├── attributionRestoreStateFromLog() ← 代码归因(ant-only)
├── restoreFromEntries() ← Context Collapse 状态
└── extractTodosFromTranscript() ← Todo 列表

adoptResumedSessionFile()
│ project.sessionFile = getTranscriptPath()
│ 继续追加写入同一 JSONL 文件

REPL 启动,messages 传入 React App

15.2 loadTranscriptFile() 的核心逻辑

async function loadTranscriptFile(filePath: string): Promise<{
messages: Map<UUID, TranscriptMessage>
summaries: Map<UUID, string>
customTitles: Map<UUID, string>
leafUuids: Set<UUID>
contentReplacements: Map<UUID, ContentReplacementRecord[]>
// ... 其他状态
}> {
const lines = await parseJSONL(filePath)

const messages = new Map<UUID, TranscriptMessage>()
const childrenMap = new Map<UUID, Set<UUID>>() // 用于找 leaf nodes

for (const entry of lines) {
if (isLegacyProgressEntry(entry)) {
// 旧版 transcript 中的 progress 条目:修复 parentUuid 链
// 将其子节点的 parentUuid 重定向到 progress 的 parentUuid
// (即"绕过"progress 节点)
continue
}

if (isTranscriptMessage(entry)) {
messages.set(entry.uuid, entry)
// 更新父→子映射
if (entry.parentUuid) {
childrenSet.add(entry.parentUuid, entry.uuid)
}
}

// 处理各类元数据 Entry...
if (entry.type === 'summary') summaries.set(...)
if (entry.type === 'custom-title') customTitles.set(...)
if (entry.type === 'content-replacement') contentReplacements.set(...)
// ...
}

// 计算 leafUuids:没有子节点的消息即为 leaf
const leafUuids = new Set<UUID>()
for (const [uuid] of messages) {
if (!childrenMap.has(uuid)) {
leafUuids.add(uuid)
}
}

// 应用 compact 剪枝(applyCompactPrune)
// 应用 snip 过滤(applySnipFilter)
// 这些操作会删减 messages Map

return { messages, leafUuids, ... }
}

15.3 哪些消息会被跳过或转换

消息类型 恢复时处理
progress(legacy) 跳过,但修复子节点的 parentUuid 链(progressBridge)
bash_progress 等 EPHEMERAL 类型 直接跳过,不进入 messages Map
compact 边界前的消息 applyCompactPrune() 删除(除非有 SEG 保留段)
snip 删除的消息 applySnipFilter() 从 Map 中移除
isSidechain=true 的消息 进入 messages Map,但 buildConversationChain() 默认不选入主链
content-replacement 恢复时替换对应 UUID 的消息内容
已被 removeMessageByUuid 删除的行 已不在文件中,自然不会被解析

compact 边界处理细节applyCompactPrune):

boundary 之前的消息 → 删除(默认)
例外:SEG(Segment)保留段
lastSeg.headUuid → lastSeg.tailUuid 之间的消息 → 保留
head.parentUuid 重写为 lastSeg.anchorUuid(接到 boundary 后)

这确保了 /compact 后的会话恢复只加载压缩后的上下文,而不是将压缩前的完整历史全部送给 API(会超出 context window)。


十六、消息类型序列化的特殊处理

16.1 图片内容的处理

在序列化消息时,图片不做截断直接存入 JSONL(base64 编码)。但图片在进入 Anthropic API 之前,有一道大小检查

// src/utils/sessionStorage.ts(写入路径)
// 图片直接序列化,JSONL 文件可能包含 MB 级 base64 数据

图片截断发生在会话恢复时的消息传递给 API 之前,而非在 JSONL 写入时。history.ts 中处理 paste 内容时会分层存储:

// history.ts:处理粘贴内容的大小分层
const MAX_PASTED_CONTENT_LENGTH = 1024 // 1KB 阈值

if (content.type === 'image') {
// 图片直接跳过,存入 image-cache(单独的缓存目录)
continue
}

if (content.content.length <= MAX_PASTED_CONTENT_LENGTH) {
// 小文本:inline 存入 history.jsonl
storedPastedContents[id] = { content: content.content, ... }
} else {
// 大文本:计算 hash,内容存入 paste-store
const hash = hashPastedText(content.content)
storedPastedContents[id] = { contentHash: hash, ... }
void storePastedText(hash, content.content) // fire-and-forget
}

16.2 history.jsonl vs <sessionId>.jsonl 的区别

这是两个完全不同的 JSONL 文件,很容易混淆:

特征 history.jsonl <sessionId>.jsonl
路径 ~/.claude/history.jsonl ~/.claude/projects/<slug>/<uuid>.jsonl
内容 用户输入的 prompt 历史(Up-arrow) 完整对话 transcript
用途 Shell-like 历史搜索(Ctrl+R) 会话恢复(--resume
写入策略 有文件锁,多 session 共享 无锁,单 session 独占
条目类型 LogEntry(display + timestamp + project) TranscriptMessage + 元数据 Entry
图片处理 跳过图片,大文本存 hash 引用 全量存储
读取方向 逆序readLinesReverse)取最近 100 条 顺序读全量

16.3 ToolUseBlock 的序列化注意点

ToolUseBlockinput 字段直接 JSON 序列化,包含工具的完整参数(如文件路径、代码内容)。对于 Write Tool,input.content 可能是几 KB 甚至几百 KB 的代码。

恢复时,这些内容作为 assistant 消息的一部分直接送给 API,参与 context,让模型”记得”自己上次写了什么代码。


十七、多 Agent 场景下的 Session 文件关系

17.1 SubAgent 的文件布局

~/.claude/projects/<project-slug>/
├── <leader-sessionId>.jsonl ← Leader Session 的完整 transcript
└── <leader-sessionId>/
└── subagents/
├── agent-<agentId1>.jsonl ← SubAgent 1 的独立 transcript
├── agent-<agentId1>.meta.json ← SubAgent 1 的元数据(agentType, cwd)
├── agent-<agentId2>.jsonl
└── <subdir>/ ← 子目录分组(workflow runs 等)
└── agent-<agentId3>.jsonl

17.2 Leader 与 SubAgent 的写入隔离

// 判断是否写入 subagent 文件
const isAgentSidechain = entry.isSidechain && entry.agentId !== undefined
const targetFile = isAgentSidechain
? getAgentTranscriptPath(asAgentId(entry.agentId!))
: sessionFile // Leader 的主文件
  • isSidechain=false:写入 Leader 的 .jsonl
  • isSidechain=true + agentId 有值:写入 subagents/agent-<id>.jsonl

17.3 SubAgent 的元数据持久化

AgentTool 启动 SubAgent 时写入 .meta.json

// 写入
await writeAgentMetadata(agentId, {
agentType: 'software-engineering', // 专家类型
worktreePath: '/path/to/worktree', // 如有 worktree 隔离
description: '实现登录功能', // 任务描述(供 resume 展示)
})

// Resume 时读取
const meta = await readAgentMetadata(agentId)
// → { agentType, worktreePath, description }
// 用于恢复正确的 AgentType,避免退化为通用 Agent

meta.json 不放入 JSONL 的原因

  1. 是 session 级别的结构元数据,与对话内容无关
  2. 避免 JSONL schema 变更带来的向后兼容问题
  3. hydrateSessionFromRemote() 会 wipe .jsonl 文件内容,但 .meta.json 以 sidecar 形式存在,不受影响

17.4 SubAgent Resume 的特殊处理

当 Leader Resume 时,已结束的 SubAgent 不会重新恢复;仍在运行的 SubAgent 通过 AgentTool 的恢复逻辑重新接入。resumeAgent() 会:

  1. 读取 subagents/agent-<id>.jsonl 重建 SubAgent 的消息历史
  2. 读取 .meta.json 确定 agentType 和 cwd
  3. 将 SubAgent 以 isSidechain=true 重新注入 Leader 的消息链

十八、面试深度题

Q1:为什么选 JSONL 而不是 SQLite 来存储会话历史?

SQLite 的问题:写一条消息需要 BEGIN → INSERT → COMMIT 三步事务,频繁写入(每个 streaming token 到来就可能触发一次 append)下事务开销显著;WAL 模式虽然改善并发,但对单进程顺序写并无必要。另外 SQLite 是 C 库,Bun/Node.js 下需要 native addon,增加打包和跨平台复杂度。

JSONL 的优势:append 是 OS 级原子操作(在多数文件系统上,单次 write syscall < PIPE_BUF 是原子的);崩溃恢复只需检查最后一行是否完整(通过 try-catch JSON.parse);纯文本,可以用任意文本工具分析;不依赖任何 native 库。

真正的权衡:JSONL 不支持随机查询(如”查找所有包含关键词 X 的消息”),但 CC 的查询模式是”读取某 session 的全量历史”,JSONL 顺序读完全胜任。

Q2:墓碑删除 vs 物理删除 — CC 为什么选择物理删除?

CC 选择的其实是物理行删除(通过 ftruncate + 重写尾部),而非追加墓碑标记。这个选择的核心原因是读取路径零成本loadTranscriptFile() 解析每一行直接进入 messages Map,不需要维护”已删除 UUID 集合”并在解析时过滤。

如果用追加墓碑,每次恢复都要 two-pass(第一遍收集 tombstones,第二遍过滤消息),增加了恢复逻辑的复杂度和内存开销。

物理删除的代价是写入时需要随机写(r+ 模式 + ftruncate),但因为目标几乎总是最后一条(失败的流式 assistant 消息),64KB 尾部读即可定位,实际延迟极低(通常 < 1ms)。

Q3:Session ID 碰撞会怎样?如何规避?

UUID v4 有 122 位随机性,在单台机器的本地文件系统上,产生碰撞的概率可以完全忽略(生成 $2.7 \times 10^{18}$ 个 UUID 才有 50% 概率出现一次碰撞)。CC 没有额外的碰撞检测逻辑。

即使理论上碰撞发生,也只是两个 session 写入同一个 .jsonl 文件,loadTranscriptFile() 会按 parentUuid 链把两个 session 的消息混在一起,导致恢复出错。但这在实践中不可能发生。

真正需要处理的是跨平台路径哈希差异:超长路径(> 200 chars)的 project slug 使用 hash 后缀,CLI 用 Bun.hash,SDK 用 Node.js 的 simpleHash,会产生不同的目录名。CC 通过 findProjectDir() 的前缀扫描来容忍这种差异。

Q4:如果 drainWriteQueue 执行到一半时进程崩溃,如何保证数据不损坏?

appendFile(即 fsAppendFile)在多数 OS 上对于 O_APPEND 的写操作是原子的,前提是单次写入不超过 PIPE_BUF(Linux 上通常 4KB)。CC 的批量写可能远超 4KB,因此理论上存在部分写(partial write)风险。

如果发生部分写,最后一行 JSON 会不完整。CC 的 parseJSONL() 用 try-catch 包裹每行的解析,忽略无法解析的行(malformed lines)。因此:即使崩溃导致最后一行截断,恢复时该行被跳过,不影响其他消息的恢复。

这是接受最终一行丢失换取简单实现的工程权衡,对于 AI 对话场景是合理的(丢失一条 assistant 回复的最后几字符,远比崩溃后无法恢复危害小)。


十一、Session 生命周期与状态机

Session States:
CREATED → ACTIVE → PAUSED → ACTIVE (恢复)
→ COMPACTING → ACTIVE
→ ABORTING → ABORTED
→ COMPLETED
→ CRASHED → RECOVERING → ACTIVE

11.1 Session 持久化触发时机

触发事件 写入内容 优先级
Turn 完成 messages[] 增量追加
Compact 完成 压缩后的 messages[] 全量写入
每 30 秒 心跳 + token usage 快照
进程退出 (SIGTERM/SIGINT) 最终全量持久化 最高
未捕获异常 尽力写入(best-effort) 应急

11.2 与数据库 WAL 的类比

Session JSONL 的写入模式类似于 SQLite 的 Write-Ahead Logging:

  • 先写日志(JSONL append),再更新内存状态
  • 崩溃恢复时从日志重建
  • 接受”最后一条可能不完整”的弱一致性

参考:Gray & Reuter (1992), Transaction Processing: Concepts and Techniques, Morgan Kaufmann.

11.3 Portable Session Storage

sessionStoragePortable.ts 支持跨设备迁移会话:将 JSONL + 元数据打包为单个文件,可复制到另一台机器继续对话。这一设计类似于 Docker 的 docker export/import 或 VM 快照迁移。

11.4 会话管理最佳实践

  • 长会话拆分:超过 50 turn 的会话建议手动触发 /compact 或开启新会话
  • 定期清理:旧的 session JSONL 可以安全删除(~/.claude/projects/)
  • 备份策略:重要会话的 JSONL 可以 git 管理,实现版本化的对话历史

涉及源文件

  • src/utils/sessionRestore.ts
  • src/utils/sessionStart.ts
  • src/utils/sessionStorage.ts
  • src/utils/sessionStoragePortable.ts
打赏
  • 微信
  • 支付宝

评论